route.test.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686
  1. /* @vitest-environment node */
  2. import { describe, it, expect, vi, beforeEach } from "vitest";
  3. vi.mock("@/lib/auth/session", () => ({
  4. getSession: vi.fn(),
  5. }));
  6. vi.mock("@/lib/db", () => ({
  7. getDb: vi.fn(),
  8. }));
  9. vi.mock("@/models/user", () => {
  10. const USER_ROLES = Object.freeze({
  11. BRANCH: "branch",
  12. ADMIN: "admin",
  13. SUPERADMIN: "superadmin",
  14. DEV: "dev",
  15. });
  16. return {
  17. default: {
  18. findById: vi.fn(),
  19. findOne: vi.fn(),
  20. findByIdAndDelete: vi.fn(),
  21. },
  22. USER_ROLES,
  23. };
  24. });
  25. vi.mock("bcryptjs", () => {
  26. const hash = vi.fn();
  27. return {
  28. default: { hash },
  29. hash,
  30. };
  31. });
  32. vi.mock("@/lib/auth/adminTempPassword", () => ({
  33. generateAdminTemporaryPassword: vi.fn(),
  34. }));
  35. import { getSession } from "@/lib/auth/session";
  36. import { getDb } from "@/lib/db";
  37. import User from "@/models/user";
  38. import { hash as bcryptHash } from "bcryptjs";
  39. import { generateAdminTemporaryPassword } from "@/lib/auth/adminTempPassword";
  40. import { PATCH, POST, DELETE, dynamic } from "./route.js";
  41. function createRequestStub(body) {
  42. return {
  43. async json() {
  44. return body;
  45. },
  46. };
  47. }
  48. describe("PATCH /api/admin/users/[userId]", () => {
  49. beforeEach(() => {
  50. vi.clearAllMocks();
  51. getDb.mockResolvedValue({});
  52. });
  53. it('exports dynamic="force-dynamic"', () => {
  54. expect(dynamic).toBe("force-dynamic");
  55. });
  56. it("returns 401 when unauthenticated", async () => {
  57. getSession.mockResolvedValue(null);
  58. const res = await PATCH(createRequestStub({}), {
  59. params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
  60. });
  61. expect(res.status).toBe(401);
  62. expect(await res.json()).toEqual({
  63. error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
  64. });
  65. });
  66. it("returns 403 when authenticated but not allowed (admin)", async () => {
  67. getSession.mockResolvedValue({
  68. userId: "u1",
  69. role: "admin",
  70. branchId: null,
  71. email: "admin@example.com",
  72. });
  73. const res = await PATCH(createRequestStub({ email: "x@y.de" }), {
  74. params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
  75. });
  76. expect(res.status).toBe(403);
  77. expect(await res.json()).toEqual({
  78. error: {
  79. message: "Forbidden",
  80. code: "AUTH_FORBIDDEN_USER_MANAGEMENT",
  81. },
  82. });
  83. });
  84. it("returns 400 when JSON parsing fails", async () => {
  85. getSession.mockResolvedValue({
  86. userId: "u2",
  87. role: "superadmin",
  88. branchId: null,
  89. email: "superadmin@example.com",
  90. });
  91. const req = { json: vi.fn().mockRejectedValue(new Error("invalid json")) };
  92. const res = await PATCH(req, {
  93. params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
  94. });
  95. expect(res.status).toBe(400);
  96. expect(await res.json()).toEqual({
  97. error: {
  98. message: "Invalid request body",
  99. code: "VALIDATION_INVALID_JSON",
  100. },
  101. });
  102. });
  103. it("returns 400 when body is not an object", async () => {
  104. getSession.mockResolvedValue({
  105. userId: "u2",
  106. role: "superadmin",
  107. branchId: null,
  108. email: "superadmin@example.com",
  109. });
  110. const res = await PATCH(createRequestStub("nope"), {
  111. params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
  112. });
  113. expect(res.status).toBe(400);
  114. expect(await res.json()).toEqual({
  115. error: {
  116. message: "Invalid request body",
  117. code: "VALIDATION_INVALID_BODY",
  118. },
  119. });
  120. });
  121. it("returns 400 when userId param is missing", async () => {
  122. getSession.mockResolvedValue({
  123. userId: "u2",
  124. role: "dev",
  125. branchId: null,
  126. email: "dev@example.com",
  127. });
  128. const res = await PATCH(createRequestStub({ email: "x@y.de" }), {
  129. params: Promise.resolve({ userId: undefined }),
  130. });
  131. expect(res.status).toBe(400);
  132. expect(await res.json()).toEqual({
  133. error: {
  134. message: "Missing required route parameter(s)",
  135. code: "VALIDATION_MISSING_PARAM",
  136. details: { params: ["userId"] },
  137. },
  138. });
  139. });
  140. it("returns 400 when userId param is invalid", async () => {
  141. getSession.mockResolvedValue({
  142. userId: "u2",
  143. role: "dev",
  144. branchId: null,
  145. email: "dev@example.com",
  146. });
  147. const res = await PATCH(createRequestStub({ email: "x@y.de" }), {
  148. params: Promise.resolve({ userId: "nope" }),
  149. });
  150. expect(res.status).toBe(400);
  151. expect(await res.json()).toMatchObject({
  152. error: { code: "VALIDATION_INVALID_FIELD" },
  153. });
  154. });
  155. it("returns 404 when user does not exist", async () => {
  156. getSession.mockResolvedValue({
  157. userId: "u2",
  158. role: "superadmin",
  159. branchId: null,
  160. email: "superadmin@example.com",
  161. });
  162. User.findById.mockReturnValue({
  163. exec: vi.fn().mockResolvedValue(null),
  164. });
  165. const res = await PATCH(createRequestStub({ email: "x@y.de" }), {
  166. params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
  167. });
  168. expect(res.status).toBe(404);
  169. expect(await res.json()).toEqual({
  170. error: {
  171. message: "Not found",
  172. code: "USER_NOT_FOUND",
  173. details: { userId: "507f1f77bcf86cd799439011" },
  174. },
  175. });
  176. });
  177. it("returns 400 when switching to role=branch without branchId (existing has none)", async () => {
  178. getSession.mockResolvedValue({
  179. userId: "u2",
  180. role: "dev",
  181. branchId: null,
  182. email: "dev@example.com",
  183. });
  184. const user = {
  185. _id: "507f1f77bcf86cd799439011",
  186. username: "x",
  187. email: "x@example.com",
  188. role: "admin",
  189. branchId: null,
  190. mustChangePassword: false,
  191. createdAt: new Date(),
  192. updatedAt: new Date(),
  193. save: vi.fn(),
  194. };
  195. User.findById.mockReturnValue({
  196. exec: vi.fn().mockResolvedValue(user),
  197. });
  198. const res = await PATCH(createRequestStub({ role: "branch" }), {
  199. params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
  200. });
  201. expect(res.status).toBe(400);
  202. expect(await res.json()).toEqual({
  203. error: {
  204. message: "Missing required fields",
  205. code: "VALIDATION_MISSING_FIELD",
  206. details: { fields: ["branchId"] },
  207. },
  208. });
  209. });
  210. it("returns 200 and updates fields; clears branchId for non-branch roles", async () => {
  211. getSession.mockResolvedValue({
  212. userId: "u2",
  213. role: "superadmin",
  214. branchId: null,
  215. email: "superadmin@example.com",
  216. });
  217. const user = {
  218. _id: "507f1f77bcf86cd799439011",
  219. username: "olduser",
  220. email: "old@example.com",
  221. role: "branch",
  222. branchId: "NL01",
  223. mustChangePassword: true,
  224. createdAt: new Date("2026-02-01T10:00:00.000Z"),
  225. updatedAt: new Date("2026-02-02T10:00:00.000Z"),
  226. save: vi.fn().mockResolvedValue(true),
  227. };
  228. User.findById.mockReturnValue({
  229. exec: vi.fn().mockResolvedValue(user),
  230. });
  231. const res = await PATCH(
  232. createRequestStub({
  233. role: "admin",
  234. mustChangePassword: false,
  235. }),
  236. {
  237. params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
  238. },
  239. );
  240. expect(res.status).toBe(200);
  241. expect(user.role).toBe("admin");
  242. expect(user.branchId).toBe(null);
  243. expect(user.mustChangePassword).toBe(false);
  244. expect(user.save).toHaveBeenCalledTimes(1);
  245. const body = await res.json();
  246. expect(body).toMatchObject({
  247. ok: true,
  248. user: {
  249. id: "507f1f77bcf86cd799439011",
  250. username: "olduser",
  251. email: "old@example.com",
  252. role: "admin",
  253. branchId: null,
  254. mustChangePassword: false,
  255. },
  256. });
  257. });
  258. it("returns 400 when username is already taken by another user", async () => {
  259. getSession.mockResolvedValue({
  260. userId: "u2",
  261. role: "dev",
  262. branchId: null,
  263. email: "dev@example.com",
  264. });
  265. const user = {
  266. _id: "507f1f77bcf86cd799439011",
  267. username: "olduser",
  268. email: "old@example.com",
  269. role: "admin",
  270. branchId: null,
  271. mustChangePassword: false,
  272. createdAt: new Date(),
  273. updatedAt: new Date(),
  274. save: vi.fn(),
  275. };
  276. User.findById.mockReturnValue({
  277. exec: vi.fn().mockResolvedValue(user),
  278. });
  279. User.findOne.mockReturnValue({
  280. select: vi.fn().mockReturnThis(),
  281. exec: vi.fn().mockResolvedValue({ _id: "507f1f77bcf86cd799439099" }),
  282. });
  283. const res = await PATCH(createRequestStub({ username: "TakenUser" }), {
  284. params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
  285. });
  286. expect(res.status).toBe(400);
  287. const body = await res.json();
  288. expect(body).toEqual({
  289. error: {
  290. message: "Username already exists",
  291. code: "VALIDATION_INVALID_FIELD",
  292. details: { field: "username", value: "takenuser" },
  293. },
  294. });
  295. });
  296. });
  297. describe("POST /api/admin/users/[userId]", () => {
  298. beforeEach(() => {
  299. vi.clearAllMocks();
  300. getDb.mockResolvedValue({});
  301. });
  302. it("returns 401 when unauthenticated", async () => {
  303. getSession.mockResolvedValue(null);
  304. const res = await POST(new Request("http://localhost/api/admin/users/x"), {
  305. params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
  306. });
  307. expect(res.status).toBe(401);
  308. expect(await res.json()).toEqual({
  309. error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
  310. });
  311. });
  312. it("returns 403 when authenticated but not allowed (admin)", async () => {
  313. getSession.mockResolvedValue({
  314. userId: "u1",
  315. role: "admin",
  316. branchId: null,
  317. email: "admin@example.com",
  318. });
  319. const res = await POST(new Request("http://localhost/api/admin/users/x"), {
  320. params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
  321. });
  322. expect(res.status).toBe(403);
  323. expect(await res.json()).toEqual({
  324. error: {
  325. message: "Forbidden",
  326. code: "AUTH_FORBIDDEN_USER_MANAGEMENT",
  327. },
  328. });
  329. });
  330. it("returns 400 for invalid userId", async () => {
  331. getSession.mockResolvedValue({
  332. userId: "u2",
  333. role: "dev",
  334. branchId: null,
  335. email: "dev@example.com",
  336. });
  337. const res = await POST(new Request("http://localhost/api/admin/users/x"), {
  338. params: Promise.resolve({ userId: "nope" }),
  339. });
  340. expect(res.status).toBe(400);
  341. expect(await res.json()).toMatchObject({
  342. error: { code: "VALIDATION_INVALID_FIELD" },
  343. });
  344. });
  345. it("returns 400 when trying to reset the current user password", async () => {
  346. getSession.mockResolvedValue({
  347. userId: "507f1f77bcf86cd799439011",
  348. role: "superadmin",
  349. branchId: null,
  350. email: "superadmin@example.com",
  351. });
  352. const res = await POST(new Request("http://localhost/api/admin/users/x"), {
  353. params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
  354. });
  355. expect(res.status).toBe(400);
  356. expect(await res.json()).toEqual({
  357. error: {
  358. message: "Cannot reset current user password",
  359. code: "VALIDATION_INVALID_FIELD",
  360. details: { field: "userId", reason: "SELF_PASSWORD_RESET_FORBIDDEN" },
  361. },
  362. });
  363. });
  364. it("returns 404 when user does not exist", async () => {
  365. getSession.mockResolvedValue({
  366. userId: "u2",
  367. role: "dev",
  368. branchId: null,
  369. email: "dev@example.com",
  370. });
  371. User.findById.mockReturnValue({
  372. exec: vi.fn().mockResolvedValue(null),
  373. });
  374. const res = await POST(new Request("http://localhost/api/admin/users/x"), {
  375. params: Promise.resolve({ userId: "507f1f77bcf86cd799439099" }),
  376. });
  377. expect(res.status).toBe(404);
  378. expect(await res.json()).toEqual({
  379. error: {
  380. message: "Not found",
  381. code: "USER_NOT_FOUND",
  382. details: { userId: "507f1f77bcf86cd799439099" },
  383. },
  384. });
  385. });
  386. it("returns 200 and resets password with mustChangePassword=true", async () => {
  387. getSession.mockResolvedValue({
  388. userId: "u2",
  389. role: "superadmin",
  390. branchId: null,
  391. email: "superadmin@example.com",
  392. });
  393. const user = {
  394. _id: "507f1f77bcf86cd799439099",
  395. username: "branch2",
  396. email: "branch2@example.com",
  397. role: "branch",
  398. branchId: "NL02",
  399. passwordHash: "old-hash",
  400. mustChangePassword: false,
  401. passwordResetToken: "token",
  402. passwordResetExpiresAt: new Date("2030-01-01"),
  403. createdAt: new Date("2026-02-01T10:00:00.000Z"),
  404. updatedAt: new Date("2026-02-02T10:00:00.000Z"),
  405. save: vi.fn().mockResolvedValue(true),
  406. };
  407. User.findById.mockReturnValue({
  408. exec: vi.fn().mockResolvedValue(user),
  409. });
  410. generateAdminTemporaryPassword.mockReturnValue("TempPass123!");
  411. bcryptHash.mockResolvedValue("hashed-temp");
  412. const res = await POST(new Request("http://localhost/api/admin/users/x"), {
  413. params: Promise.resolve({ userId: "507f1f77bcf86cd799439099" }),
  414. });
  415. expect(res.status).toBe(200);
  416. expect(generateAdminTemporaryPassword).toHaveBeenCalledTimes(1);
  417. expect(bcryptHash).toHaveBeenCalledWith("TempPass123!", 12);
  418. expect(user.passwordHash).toBe("hashed-temp");
  419. expect(user.mustChangePassword).toBe(true);
  420. expect(user.passwordResetToken).toBe(null);
  421. expect(user.passwordResetExpiresAt).toBe(null);
  422. expect(user.save).toHaveBeenCalledTimes(1);
  423. expect(await res.json()).toMatchObject({
  424. ok: true,
  425. temporaryPassword: "TempPass123!",
  426. user: {
  427. id: "507f1f77bcf86cd799439099",
  428. username: "branch2",
  429. email: "branch2@example.com",
  430. role: "branch",
  431. branchId: "NL02",
  432. mustChangePassword: true,
  433. },
  434. });
  435. });
  436. });
  437. describe("DELETE /api/admin/users/[userId]", () => {
  438. beforeEach(() => {
  439. vi.clearAllMocks();
  440. getDb.mockResolvedValue({});
  441. });
  442. it("returns 401 when unauthenticated", async () => {
  443. getSession.mockResolvedValue(null);
  444. const res = await DELETE(
  445. new Request("http://localhost/api/admin/users/x"),
  446. {
  447. params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
  448. },
  449. );
  450. expect(res.status).toBe(401);
  451. expect(await res.json()).toEqual({
  452. error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
  453. });
  454. });
  455. it("returns 403 when authenticated but not allowed (admin)", async () => {
  456. getSession.mockResolvedValue({
  457. userId: "u1",
  458. role: "admin",
  459. branchId: null,
  460. email: "admin@example.com",
  461. });
  462. const res = await DELETE(
  463. new Request("http://localhost/api/admin/users/x"),
  464. {
  465. params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
  466. },
  467. );
  468. expect(res.status).toBe(403);
  469. expect(await res.json()).toEqual({
  470. error: {
  471. message: "Forbidden",
  472. code: "AUTH_FORBIDDEN_USER_MANAGEMENT",
  473. },
  474. });
  475. expect(User.findByIdAndDelete).not.toHaveBeenCalled();
  476. });
  477. it("returns 400 for invalid userId", async () => {
  478. getSession.mockResolvedValue({
  479. userId: "u2",
  480. role: "dev",
  481. branchId: null,
  482. email: "dev@example.com",
  483. });
  484. const res = await DELETE(
  485. new Request("http://localhost/api/admin/users/x"),
  486. {
  487. params: Promise.resolve({ userId: "nope" }),
  488. },
  489. );
  490. expect(res.status).toBe(400);
  491. expect(await res.json()).toMatchObject({
  492. error: { code: "VALIDATION_INVALID_FIELD" },
  493. });
  494. });
  495. it("returns 400 when trying to delete the current user (self delete)", async () => {
  496. getSession.mockResolvedValue({
  497. userId: "507f1f77bcf86cd799439011",
  498. role: "superadmin",
  499. branchId: null,
  500. email: "superadmin@example.com",
  501. });
  502. const res = await DELETE(
  503. new Request("http://localhost/api/admin/users/x"),
  504. {
  505. params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
  506. },
  507. );
  508. expect(res.status).toBe(400);
  509. expect(await res.json()).toEqual({
  510. error: {
  511. message: "Cannot delete current user",
  512. code: "VALIDATION_INVALID_FIELD",
  513. details: { field: "userId", reason: "SELF_DELETE_FORBIDDEN" },
  514. },
  515. });
  516. expect(User.findByIdAndDelete).not.toHaveBeenCalled();
  517. });
  518. it("returns 404 when user does not exist", async () => {
  519. getSession.mockResolvedValue({
  520. userId: "u2",
  521. role: "dev",
  522. branchId: null,
  523. email: "dev@example.com",
  524. });
  525. User.findByIdAndDelete.mockReturnValue({
  526. select: vi.fn().mockReturnThis(),
  527. exec: vi.fn().mockResolvedValue(null),
  528. });
  529. const res = await DELETE(
  530. new Request("http://localhost/api/admin/users/x"),
  531. {
  532. params: Promise.resolve({ userId: "507f1f77bcf86cd799439099" }),
  533. },
  534. );
  535. expect(res.status).toBe(404);
  536. expect(await res.json()).toEqual({
  537. error: {
  538. message: "Not found",
  539. code: "USER_NOT_FOUND",
  540. details: { userId: "507f1f77bcf86cd799439099" },
  541. },
  542. });
  543. });
  544. it("returns 200 and deleted user payload on success", async () => {
  545. getSession.mockResolvedValue({
  546. userId: "u2",
  547. role: "superadmin",
  548. branchId: null,
  549. email: "superadmin@example.com",
  550. });
  551. User.findByIdAndDelete.mockReturnValue({
  552. select: vi.fn().mockReturnThis(),
  553. exec: vi.fn().mockResolvedValue({
  554. _id: "507f1f77bcf86cd799439099",
  555. username: "todelete",
  556. email: "todelete@example.com",
  557. role: "branch",
  558. branchId: "NL01",
  559. mustChangePassword: true,
  560. createdAt: new Date("2026-02-01T10:00:00.000Z"),
  561. updatedAt: new Date("2026-02-02T10:00:00.000Z"),
  562. }),
  563. });
  564. const res = await DELETE(
  565. new Request("http://localhost/api/admin/users/x"),
  566. {
  567. params: Promise.resolve({ userId: "507f1f77bcf86cd799439099" }),
  568. },
  569. );
  570. expect(res.status).toBe(200);
  571. const body = await res.json();
  572. expect(body).toMatchObject({
  573. ok: true,
  574. user: {
  575. id: "507f1f77bcf86cd799439099",
  576. username: "todelete",
  577. email: "todelete@example.com",
  578. role: "branch",
  579. branchId: "NL01",
  580. mustChangePassword: true,
  581. },
  582. });
  583. });
  584. });